Skip to content

Conversation

@bracevac
Copy link
Contributor

@bracevac bracevac commented Oct 26, 2025

Add capture checking support for lazy vals

Fixes #21601

Extends capture checking to handle lazy vals, treating them like parameterless methods for capture tracking.

Key Changes

  1. Lazy val environments.
    Lazy vals now create their own environment to track initializer captures, separate from the result type's capture set.

  2. Member selection.
    When accessing a lazy val member (e.g., t.lazyMember), the qualifier t is charged to the capture set, reflecting that initialization may use the qualifier's capabilities.

  3. Read-only initialization.
    For lazy val fields of Mutable classes, their initializers cannot call update methods on non-local exclusive capabilities (just like non-update methods).

  4. Documentation in the language reference.

  5. Fix errors in the standard library revealed by proper lazy vals support.

@bracevac
Copy link
Contributor Author

bracevac commented Oct 27, 2025

Under this scheme, private lazy val _sorted: Seq[A] in scala/collection/SeqView.scala will (rightfully) capture var underlying : SomeSeqOps[A]^, which in turn will blow up on this line

override def to[C1](factory: Factory[A, C1]): C1 = _sorted.to(factory)

Because we expect to return a pure C1, whereas in fact it isn't now.

(I'm glad that this appears to be the only place in the library that causes trouble).

@bracevac
Copy link
Contributor Author

After some more thought, it's actually quite puzzling that the _sorted.to(factory) call would be impure. We'd expect the result to be pure, and only charge the _sorted initializer's capture set to the context.

I've now reverted to the previous solution with just includeCallCaptures. This version does not support treating a lazy val x: Foo = ... with impure initializer as a trackable resource. E.g., () => x is not of type () ->{x} Foo, but () ->{<init>} Foo, and we can't say that {x} <: {<init>}.

@bracevac bracevac force-pushed the cc-lazy-vals branch 6 times, most recently from a1e1c6f to 186df5e Compare November 2, 2025 16:12
@bracevac bracevac marked this pull request as ready for review November 2, 2025 16:13
@bracevac bracevac requested a review from a team as a code owner November 2, 2025 16:13
Copy link
Contributor

@natsukagami natsukagami left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass LGTM

Comment on lines 221 to 227
def methodMember(): String
lazy val lazyMember: String

def test(t: T^): Unit =
// Both require {t} in the capture set
val m: () ->{t} String = () => t.methodMember()
val l: () ->{t} String = () => t.lazyMember
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps just use methods with no parameter lists?

trait T:
  def methodMember: String

def apply(i: Int): A = _reversed.apply(i)
def length: Int = len
def iterator: Iterator[A] = Iterator.empty ++ _reversed.iterator // very lazy
def iterator: Iterator[A]^{this} = Iterator.empty ++ _reversed.iterator // very lazy
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future head-scratchers: ++ on iterators are by-name on the RHS, and so will capture this by virtue of accessing the lazy val _reversed

Copy link
Contributor

@odersky odersky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to add check files for neg tests. That way we can also verify that the quality of the error messages does not regress.


// Test case 1: Read-only access in initializer - should be OK
lazy val lazyVal: () ->{r.rd} Int =
val current = r2.get()
Copy link
Contributor

@odersky odersky Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normal lazy vals should be able to capture exclusive capabilities. We are only restricting lazyvals in Mutable classes. These should behave like read-only methods. So the code in the compiler and the test here should be adapted.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit that implements these suggestions.

@odersky odersky assigned bracevac and unassigned odersky Nov 3, 2025
@odersky
Copy link
Contributor

odersky commented Nov 4, 2025

It seems there's an issue with the following test program:

class IO extends caps.SharedCapability:
  def write(): Unit = ()

class C(val io: IO):
  lazy val x = () => io.write()
  val y: () ->{io} Unit = x
  val c = C(io)
  lazy val l1 = () => c.io.write()
  lazy val l2 = () => this.io.write()
  val s1 = () => c.io.write()
  val s2 = () => this.io.write()

def test(io: IO) =
  lazy val x = () => io.write()
  val y: () ->{io} Unit = x
  val c = C(io)
  lazy val z = () => c.io.write()

If I compile that with -Xprint:cc, I see:

[[syntax trees at end of                        cc]] // lazyvals6.scala
package <empty> {
  @SourceFile("lazyvals6.scala") class IO() extends Object(), 
    (scala.caps.SharedCapability^) {
    def write(): Unit = ()
  }
  @SourceFile("lazyvals6.scala") class C(io: IO^) extends Object() {
    val io: IO^
    lazy val x: () ->{C.this.io} Unit = () => C.this.io.write()
    val y: () ->{C.this.io} Unit = this.x
    val c: C{val io: IO^{C.this.io}}^{C.this.io} = new C(C.this.io)
    lazy val l1: () ->{} Unit = () => this.c.io.write() 
    lazy val l2: () ->{C.this.io} Unit = () => this.io.write()
    val s1: () ->{} Unit = () => this.c.io.write()          
    val s2: () ->{C.this.io} Unit = () => this.io.write()
  }
  final lazy module val lazyvals6$package: lazyvals6$package =
    new lazyvals6$package()
  @SourceFile("lazyvals6.scala") final module class lazyvals6$package() extends
    Object() {
    private[this] type $this = lazyvals6$package.type
    private def writeReplace(): AnyRef =
      new scala.runtime.ModuleSerializationProxy(classOf[lazyvals6$package.type]
        )
    def test(io: IO^): Unit =
      {
        lazy val x: () ->{io} Unit = () => io.write()
        val y: () ->{io} Unit = x
        val c: C{val io: IO^{io}}^{io} = new C(io)
        lazy val z: () ->{c.io} Unit = () => c.io.write()
        ()
      }
  }
}

The types of l1 and s1 look wrong to me: I believe should each have type () ->{c.io} Unit. Curiously, it works if the prefix is this.io instead of c.io and it also works outside class C, e.g. in the value of z. I tested this with and without my changes, the results are the same.

@odersky
Copy link
Contributor

odersky commented Nov 4, 2025

I am actually not sure this has anything to do with lazyvals since the results are the same for s1 and l1.

@bracevac
Copy link
Contributor Author

bracevac commented Nov 4, 2025

I've added the requested changes, rebased, and squashed @odersky

@bracevac bracevac merged commit 13372c9 into scala:main Nov 4, 2025
53 checks passed
@bracevac bracevac deleted the cc-lazy-vals branch November 4, 2025 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement correct capture check for lazy vals

3 participants